All files / src/app/api/affiliates/[code]/track route.ts

0% Statements 0/160
100% Branches 0/0
0% Functions 0/1
0% Lines 0/160

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161                                                                                                                                                                                                                                                                                                                                 
export const dynamic = "force-dynamic";

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { logger } from "@/lib/logging";
import { affiliateSystem } from "@/lib/affiliate-system";
import { nanoid } from "nanoid";
import {
  successResponse,
  ApiError,
  ApiSuccessResponse,
  ApiErrorResponse,
  handleApiError } from "@/lib/api";

// Cookie settings
const COOKIE_NAME = "affiliate_ref";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds

interface RouteParams {
  params: Promise<{ code: string }>;
}

/**
 * GET /api/affiliates/[code]/track
 * Track an affiliate click and redirect to destination
 */
export async function GET(request: NextRequest, { params }: RouteParams) {
  try {
    const { code } = await params;

    // Find the affiliate by code
    const affiliate = await prisma.affiliate.findUnique({
      where: { code }});

    if (!affiliate || affiliate.status !== "ACTIVE") {
      // Redirect to homepage if invalid affiliate
      return NextResponse.redirect(
        new URL("/", process.env.NEXT_PUBLIC_BASE_URL || request.url)
      );
    }

    // Get tracking data
    const { searchParams } = new URL(request.url);
    const redirect = searchParams.get("redirect") || "/";
    const ipAddress =
      request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
      request.headers.get("x-real-ip") ||
      "unknown";
    const userAgent = request.headers.get("user-agent") || "";
    const referrer = request.headers.get("referer") || "";

    // Generate or get visitor ID
    let visitorId = request.cookies.get("visitor_id")?.value;
    if (!visitorId) {
      visitorId = nanoid(16);
    }

    // Track the click
    await affiliateSystem.trackClick({
      affiliateCode: affiliate.code,
      visitorId,
      ipAddress,
      userAgent,
      referer: referrer,
      landingPage: redirect});

    logger.info("Affiliate click tracked", {
      category: "API",
      affiliateCode: affiliate.code,
      visitorId});

    // Create redirect response with cookies
    const response = NextResponse.redirect(
      new URL(redirect, process.env.NEXT_PUBLIC_BASE_URL || request.url)
    );

    // Set affiliate cookie
    response.cookies.set(COOKIE_NAME, affiliate.code, {
      maxAge: COOKIE_MAX_AGE,
      path: "/",
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax"});

    // Set visitor ID cookie if new
    if (!request.cookies.get("visitor_id")) {
      response.cookies.set("visitor_id", visitorId, {
        maxAge: COOKIE_MAX_AGE,
        path: "/",
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax"});
    }

    return response;
  } catch (error) {
    logger.error("Error tracking affiliate click", error instanceof Error ? error : new Error(String(error)), { category: "API" });
    // On error, still redirect to prevent bad user experience
    return NextResponse.redirect(
      new URL("/", process.env.NEXT_PUBLIC_BASE_URL || request.url)
    );
  }
}

/**
 * POST /api/affiliates/[code]/track
 * Track a click via API (for programmatic tracking)
 */
export async function POST(
  request: NextRequest,
  { params }: RouteParams
): Promise<NextResponse<ApiSuccessResponse<unknown> | ApiErrorResponse>> {
  try {
    const { code } = await params;

    // Find the affiliate by code
    const affiliate = await prisma.affiliate.findUnique({
      where: { code }});

    if (!affiliate || affiliate.status !== "ACTIVE") {
      throw ApiError.notFound("Affiliate");
    }

    const body = await request.json();
    const ipAddress =
      request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
      request.headers.get("x-real-ip") ||
      "unknown";
    const userAgent = request.headers.get("user-agent") || "";

    // Generate visitor ID if not provided
    const visitorId = body.visitorId || nanoid(16);

    // Track the click
    const result = await affiliateSystem.trackClick({
      affiliateCode: affiliate.code,
      visitorId,
      ipAddress,
      userAgent,
      referer: body.referrer || "",
      landingPage: body.landingPage || "/"});

    if (!result.success) {
      throw ApiError.internal("Failed to track click");
    }

    logger.info("Affiliate click tracked via API", {
      category: "API",
      affiliateCode: affiliate.code,
      visitorId,
      clickId: result.clickId});

    return successResponse({
      clickId: result.clickId,
      visitorId,
      affiliateCode: affiliate.code});
  } catch (error) {
    return handleApiError(error, `POST /api/affiliates/[code]/track`);
  }
}